iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0

「說出口就收不回。所以真正的紀律不是講得漂亮,是講之前先把每一句拿去對過自己的底稿。」
——《阿帕契開源審計錄》¹ 卷二·自查篇²

幕間
七號守在重傷的預言家身旁。
「聽好,別猜誰是狼。」預言家氣若游絲,「問:若他是好人,這句話該長什麼樣?再比對實際差了多少。」
七號含淚將這句話一字不漏抄在羊皮紙頂端。

天亮,進入上警環節。前置位有人想起跳預言家扔查殺,測後置位臨場反應。輪到七號發言,她卻先停了幾秒,低頭在羊皮紙上劃掉了兩行——那是她昨晚寫下的兩個懷疑,但翻回去查,其中一個根本沒有夜次紀錄佐證,另一個她把投票方向記反了,照著講出去只會被人反咬。劃掉之後,她只講剩下的那一條,講得很短,短到不像在爭警徽,但每個字都有紀錄可對。輪到二號,法官問他警徽流要怎麼帶、心裡想投誰,他照例沒有先給出一個目標,而是說「等大家都講完我再表態比較公平」,然後把四號剛講完的最後半句接了過去,順著往下推。七號後來在旁邊寫了一行小字:她開始要求自己,開口前先把整段話拿去跟表格對一遍,對不上的、講不出證據的,就不講。而我坐在狼這邊想的是——一個工程師要怎麼在把東西交出去、被所有人看見之前,先攔下自己的瑕疵?

兩道關卡:本機與 CI

把品質檢查想成兩道門。第一道在你自己的機器上,每次存檔、每次 commit 就跑,錯了當場擋下來重寫,代價是幾秒鐘、沒有別人看見。第二道在 CI,push 上去之後跑,錯了就把整條合併請求標紅,代價是一次來回、審查者的時間、還有你名字旁邊那個紅叉。兩道門檢查的東西大致重疊,這是故意的:本機那道是給你自己方便,可以被繞過(--no-verify);CI 那道是給團隊一個不能繞過的保證。七號今晚做的,就是在自己嘴巴前面補上了第一道門——把「想到什麼講什麼」換成「先過一遍自查再講」。

為什麼 clippy 敢有幾百條規則,golangci-lint 選擇聚合

Rust 的 cargo clippy 內建幾百條規則,從「這個 clone() 是多餘的」到「這個 match 可以用 if let 簡化」都會提示。它敢這樣做,是因為 Rust 有強型別、所有權資訊和 MIR 中間表示,clippy 能相當有把握地判斷一段程式碼「語意上等價但寫得更差」,誤報率低到值得預設全開。rustfmt 負責格式,沒有討論空間;#![deny(warnings)] 可以把所有警告升級成編譯失敗——但在有大量依賴的專案裡這其實是個陷阱:某個上游函式庫在新版編譯器下冒出一條新的 deprecation 警告,你的建置就整個掛掉,而問題根本不在你的程式碼。成熟專案通常只在 CI 對自己的 crate deny,不對整棵樹。

Go 的 golangci-lint 走另一條路:它本身不寫規則,而是把 staticcheck、errcheck、ineffassign、govet 等數十個獨立 linter 聚合起來,用一份 .golangci.yml 統一開關與設定。Go 的語言設計刻意精簡,社群文化偏好「工具少而正交」,與其養一個包山包海的巨獸,不如讓每個 linter 專注一件事、各自演進,由聚合器負責跑得快、輸出格式一致。

Java 的靜態分析分兩層。Checkstyle 讀原始碼,管命名、排版、import 順序這類風格問題。SpotBugs(FindBugs 的後繼)則是分析編譯後的位元碼——這讓它能看到原始碼 linter 永遠看不到的東西:跨方法的空指標流動、equals() 和 hashCode() 不成對、序列化欄位漏標、在 finally 裡吞掉例外、字串在迴圈裡用 + 串接產生的隱藏物件配置。位元碼是編譯器攤平所有語法糖之後的結果,很多 bug 在那一層才看得清楚。

Python 的 ruff 用 Rust 寫成,一支工具同時做 formatter 和幾百條 lint 規則,快到可以掛在編輯器的存檔事件上即時跑。但 ruff 看的是語法,真正把 Python 從「動態到出事才知道」拉回來的是 mypy(或 pyright):它把 pyproject.toml 裡的 type hints 從「寫給人看的註解」升級成「對不上就擋 CI 的約束」,讓你在跑之前就抓到「這裡傳了 None 給一個宣告不吃 None 的函式」。

# .pre-commit-config.yaml -- the local gate, runs before every commit
repos:
  - repo: local
    hooks:
      - id: gofmt
        name: gofmt
        entry: gofmt -l -w
        language: system
        types: [go]
      - id: golangci-lint
        name: golangci-lint
        entry: golangci-lint run --timeout 3m
        language: system
        pass_filenames: false

這份設定的重點是 pre-commit 這個框架:它把「commit 前要跑哪些檢查」寫成宣告式的清單,新人 clone 下來裝一次就有同一套門檻,不必靠口頭交代。pass_filenames: false 讓 golangci-lint 自己決定掃描範圍(它需要看整個套件才能做跨檔案分析),--timeout 則是提醒你:本機這道門必須快,慢到讓人想繞過就失去意義了。

// cargo clippy would flag the first version; the second is what it wants.
fn count_guilty(ballots: &Vec<String>) -> usize {            // clippy: use &[String]
    ballots.iter().filter(|b| b.to_string() == "guilty").count() // clippy: needless allocation
}

fn count_guilty_clean(ballots: &[String]) -> usize {
    ballots.iter().filter(|b| *b == "guilty").count()
}

第一個版本兩處會被 clippy 挑。&Vec<String> 應該寫成 &[String],因為前者強迫呼叫端一定要有一個 Vec,後者連陣列切片都能傳,介面更寬。b.to_string() 在比較裡每一輪都配置一個新字串,純粹浪費;直接解參考比較就好。這兩點都不影響正確性,但 clippy 把資深 Rust 開發者「這樣寫以後會出事或會慢」的直覺,變成一條有編號、會自動觸發的規則——這正是 linter 的價值:把口耳相傳的品味變成可執行的檢查。

格式化不是品味,是消除爭論

新手常把 formatter 當成「風格偏好」而抗拒。真正的價值不在美觀,在於它把「大括號要不要換行」「縮排幾格」「import 怎麼排」這類永遠吵不完、又完全不影響正確性的問題,一次性地從團隊的對話裡刪掉。四個語言的社群不約而同走向「零設定、不可爭論」的 formatter——gofmt、rustfmt、ruff format、google-java-format——省下來的不是排版時間,是 code review 裡的口水和新人的猶豫。

linter 也有它的反面,那就是「抑制的紀律」。每個工具都提供 //nolint、#[allow(...)]、# noqa、@SuppressWarnings 這類逃生門,讓你在確定某條規則這裡不適用時關掉它。健康的做法是:每一次抑制都要在同一行寫清楚理由,而且 CI 會檢查「有沒有抑制了一條根本不會觸發的規則」——因為那種殭屍抑制會累積,最後沒人敢動。七號劃掉那兩行懷疑時做的正是這件事:她沒有假裝那兩條不存在,她留下了劃掉的痕跡,讓別人知道她查過、而且查不過。

檢查管線

https://ithelp.ithome.com.tw/upload/images/20260924/201836846NfAfzQGqn.png

第三道門:安全這件事,值得單獨自查一次

前面兩道門管的是風格與型別,但有一類問題兩者都照不到——這段變更會不會不小心開了一個安全洞。格式化工具不懂什麼是安全,型別檢查也不管一段字串有沒有被拼進 SQL 查詢。這正是本文寫作所用的 Claude Code 環境裡另外劃出的一道門:一個獨立於 lint/型別檢查之外的安全自查流程,專門審視待提交的變更本身有沒有引入注入、權限、憑證外洩這類問題,跑在人類審查者看到 diff 之前。放進七號的比喻裡,這就像她劃掉那兩行懷疑之前,多問了自己一句「這句話會不會反而幫到狼」——一道專門為「這東西會不會被拿來害人」而設的自查,跟「這句話講得順不順」是兩件事,值得分開問。

這個習慣的後果

上警那輪,七號的發言短得幾乎不像在爭警徽,但沒有一句被人抓到破綻,因為她把講不清楚的都自己先劃掉了。對我這隻狼來說,她變得更難釣——以前平民只要嘴一快,就會講出可以被反過來咬的話,被抓住一個小矛盾就能帶偏整場;現在她開口前那道門把這種機會關掉了,她講出來的東西全部帶著紀錄,咬不動。二號那種「等別人講完再附議、從不自己先提一個目標」的打法,單看每一輪都沒破綻,可是七號的表格開始記下一件事:翻遍所有夜次,他從來沒有一次「先於別人的獨立判斷」。這條紀錄現在還只是一行小字,但表格會一直長。我這隻狼也開始有個甩不掉的感覺:法官不是這局的頂點。他報時、敲槌、裁決,可是他背後好像還站著什麼更大的東西,連他也只是被派來看門的。

讀完這篇,你該做的是挑一個自己的專案,裝上該語言的 formatter 與 linter,寫一份最小設定(先只開最沒爭議的幾條規則),再用 pre-commit 或 Git hook 讓它在 commit 前自動跑,最後在 CI 加一個「lint 或型別檢查不過就擋合併」的關卡,親眼看它第一次擋下你自己的東西。Go 官方文件的 go vet 說明、golangci-lint 官方文件 與 Rust Clippy 官方文件 都值得通讀一遍,pre-commit 官方文件 則說明如何把這些工具串成本機的第一道關卡。

參考資料與延伸閱讀


¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。
² 註:現實彩蛋——今天,2026 年 9 月 26 日,中秋節的隔天,就是 Day 09 提過的 Claude Taipei 中秋烤肉聚會。活動頁上寫著:台灣三位 Claude 大使——Natalie Lin(Day 19 提過)、Justin Shaw(Day 15 提過)與龍哥(Day 9 提過)——首次一起合辦活動。台北今晚掛的是中秋隔天依然又圓又亮的一輪真月亮;城堡裡那輪被陰影啃著的月亮是另一回事,這條線留到後面再收。


上一篇
Day 19|同一個版本的規則書
下一篇
Day 21|設計模式的自然消失
系列文
狼人自爆的心路歷程:一個「AI人」的30天自學修煉 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言